﻿// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics;
using Microsoft.Build.Evaluation;
using Microsoft.DotNet.Cli.Utils;
using Microsoft.Extensions.Logging;
using Microsoft.TemplateEngine.Abstractions;
using Microsoft.TemplateEngine.Utils;
using MSBuildProject = Microsoft.Build.Evaluation.Project;

namespace Microsoft.DotNet.Cli.Commands.New.MSBuildEvaluation;

internal class MSBuildEvaluator : IIdentifiedComponent
{
    private readonly ProjectCollection _projectCollection = new();
    private readonly object _lockObj = new();

    private IEngineEnvironmentSettings? _settings;
    private ILogger? _logger;
    private MSBuildEvaluationResult? _cachedEvaluationResult;
    private readonly string _outputDirectory;
    private readonly string? _projectFullPath;
    internal MSBuildEvaluator()
    {
        _outputDirectory = Directory.GetCurrentDirectory();
    }

    internal MSBuildEvaluator(string? outputDirectory = null, string? projectPath = null)
    {
        _outputDirectory = outputDirectory ?? Directory.GetCurrentDirectory();
        _projectFullPath = projectPath != null ? Path.GetFullPath(projectPath) : null;
    }

    public Guid Id => Guid.Parse("{6C2CB5CA-06C3-460A-8ADB-5F21E113AB24}");

    /// <summary>
    /// Evaluates the project at current location.
    /// Current location is specified by `--output` or is current working directory.
    /// The location is set once when component is created.
    /// If location changes, recreate the component.
    /// </summary>
    /// <param name="engineEnvironmentSettings"></param>
    /// <returns>MSBuild project evaluation result.</returns>
    internal MSBuildEvaluationResult EvaluateProject(IEngineEnvironmentSettings engineEnvironmentSettings)
    {
        lock (_lockObj)
        {
            //we cache result of evaluation if instance of environment settings used is the same.
            //reason: the component is only used in dotnet CLI, which execution is short lived.
            //we don't care about changes done to project during command execution.
            if (_settings == null || _settings != engineEnvironmentSettings || _cachedEvaluationResult == null)
            {
                _settings = engineEnvironmentSettings;
                _logger = _settings.Host.LoggerFactory.CreateLogger(nameof(MSBuildEvaluator));
                _cachedEvaluationResult = EvaluateProjectInternal(_settings);
            }
            return _cachedEvaluationResult;
        }
    }

    /// <summary>
    /// Forces cache reset.
    /// </summary>
    internal void ResetCache()
    {
        lock (_lockObj)
        {
            _settings = null;
            _cachedEvaluationResult = null;
        }
    }

    private MSBuildEvaluationResult EvaluateProjectInternal(IEngineEnvironmentSettings engineEnvironmentSettings)
    {
        _logger?.LogDebug("Output directory is: {0}.", _outputDirectory);
        _logger?.LogDebug("Project full path is: {0}.", _projectFullPath ?? "<null>");

        string projectPath;
        if (string.IsNullOrEmpty(_projectFullPath))
        {
            IReadOnlyList<string> foundFiles = [];
            try
            {
                foundFiles = FileFindHelpers.FindFilesAtOrAbovePath(engineEnvironmentSettings.Host.FileSystem, _outputDirectory, "*.*proj");
                _logger?.LogDebug("Found project files: {0}.", string.Join("; ", foundFiles));
            }
            catch (Exception e)
            {
                //do nothing
                //in case of exception, no project found result is used.
                _logger?.LogDebug("Exception occurred when searching for the project file: {0}", e.Message);
            }

            if (foundFiles.Count == 0)
            {
                _logger?.LogDebug("No project found.");
                return MSBuildEvaluationResult.CreateNoProjectFound(_outputDirectory);
            }
            if (foundFiles.Count > 1)
            {
                _logger?.LogDebug("Multiple projects found.");
                return MultipleProjectsEvaluationResult.Create(foundFiles);
            }
            projectPath = Path.GetFullPath(foundFiles.Single());
        }
        else
        {
            projectPath = _projectFullPath;
        }

        Stopwatch watch = new();
        Stopwatch innerBuildWatch = new();
        bool IsSdkStyleProject = false;
        IReadOnlyList<string>? targetFrameworks = null;
        string? targetFramework = null;
        MSBuildEvaluationResult? result = null;

        try
        {
            watch.Start();
            _logger?.LogDebug("Evaluating project: {0}", projectPath);
            MSBuildProject evaluatedProject = RunEvaluate(projectPath);

            //if project is using Microsoft.NET.Sdk, then it is SDK-style project.
            IsSdkStyleProject = evaluatedProject.GetProperty("UsingMicrosoftNETSDK")?.EvaluatedValue == "true";
            _logger?.LogDebug("SDK-style project: {0}", IsSdkStyleProject);

            targetFrameworks = evaluatedProject.GetProperty("TargetFrameworks")?.EvaluatedValue?.Split(";");
            _logger?.LogDebug("Target frameworks: {0}", string.Join("; ", targetFrameworks ?? []));
            targetFramework = evaluatedProject.GetProperty("TargetFramework")?.EvaluatedValue;
            _logger?.LogDebug("Target framework: {0}", targetFramework ?? "<null>");

            if (!IsSdkStyleProject || string.IsNullOrWhiteSpace(targetFramework) && targetFrameworks == null)
            {
                //For non SDK style project, we cannot evaluate more info. Also there is no indication, whether the project
                //was restored or not, so it is not checked.
                _logger?.LogDebug("Project is non-SDK style, cannot evaluate restore status, succeeding.");
                return result = NonSDKStyleEvaluationResult.CreateSuccess(projectPath, evaluatedProject);
            }

            //For SDK-style project, if the project was restored "RestoreSuccess" property will be set to true.
            if (!evaluatedProject.GetProperty("RestoreSuccess")?.EvaluatedValue.Equals("true", StringComparison.OrdinalIgnoreCase) ?? true)
            {
                _logger?.LogDebug("Project is not restored, exiting.");
                return result = MSBuildEvaluationResult.CreateNoRestore(projectPath);
            }

            //If target framework is set, no further evaluation is needed.
            if (!string.IsNullOrWhiteSpace(targetFramework))
            {
                _logger?.LogDebug("Project is SDK style, single TFM:{0}, evaluation succeeded.", targetFramework);
                return result = SDKStyleEvaluationResult.CreateSuccess(projectPath, targetFramework, evaluatedProject);
            }

            //If target framework is not set, then presumably it is multi-target project.
            //If there are no target frameworks, it is not expected.
            if (targetFrameworks == null)
            {
                _logger?.LogDebug("Project is SDK style, but does not specify the framework.");
                return result = MSBuildEvaluationResult.CreateFailure(projectPath, string.Format(CliCommandStrings.MSBuildEvaluator_Error_NoTargetFramework, projectPath));
            }

            //For multi-target project, we need to do additional evaluation for each target framework.
            Dictionary<string, MSBuildProject?> evaluatedTfmBasedProjects = [];
            innerBuildWatch.Start();
            foreach (string tfm in targetFrameworks)
            {
                _logger?.LogDebug("Evaluating project for target framework: {0}", tfm);
                evaluatedTfmBasedProjects[tfm] = RunEvaluate(projectPath, tfm);
            }
            innerBuildWatch.Stop();
            _logger?.LogDebug("Project is SDK style, multi-target, evaluation succeeded.");
            return result = MultiTargetEvaluationResult.CreateSuccess(projectPath, evaluatedProject, evaluatedTfmBasedProjects);

        }
        catch (Exception e)
        {
            _logger?.LogDebug("Unexpected error: {0}", e);
            return result = MSBuildEvaluationResult.CreateFailure(projectPath, e.Message);
        }
        finally
        {
            watch.Stop();
            innerBuildWatch.Stop();

            string? targetFrameworksString = null;

            if (targetFrameworks != null)
            {
                targetFrameworksString = string.Join(",", targetFrameworks.Select(tfm => Sha256Hasher.HashWithNormalizedCasing(tfm)));
            }
            else if (targetFramework != null)
            {
                targetFrameworksString = Sha256Hasher.HashWithNormalizedCasing(targetFramework);
            }

            Dictionary<string, string?> properties = new()
            {
                { "ProjectPath",  Sha256Hasher.HashWithNormalizedCasing(projectPath)},
                { "SdkStyleProject", IsSdkStyleProject.ToString() },
                { "Status", result?.Status.ToString() ?? "<null>"},
                { "TargetFrameworks", targetFrameworksString ?? "<null>"},
            };

            Dictionary<string, double> measurements = new()
            {
                { "EvaluationTime",  watch.ElapsedMilliseconds },
                { "InnerEvaluationTime",  innerBuildWatch.ElapsedMilliseconds }
            };

            TelemetryEventEntry.TrackEvent("new/msbuild-eval", properties, measurements);
        }
    }

    private MSBuildProject RunEvaluate(string projectToLoad, string? tfm = null)
    {
        if (!File.Exists(projectToLoad))
        {
            throw new FileNotFoundException(message: null, projectToLoad);
        }

        MSBuildProject? project = GetLoadedProject(projectToLoad, tfm);
        if (project != null)
        {
            return project;
        }
        var globalProperties = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        if (!string.IsNullOrWhiteSpace(tfm))
        {
            globalProperties["TargetFramework"] = tfm;
        }

        //We do only best effort here, also the evaluation should be fast vs complete; therefore ignoring imports errors.
        //The result of evaluation is used for the following:
        // - determining if the template can be run in the following context(constraints) based on Project Capabilities
        // - determining properties values that will be used in template content
        //The cost of the error is not substantial:
        //- worst case scenario the user can create a template, which should not be allowed to and it fails to compile / build-- > likely user will remove it or fix it manually then
        //- or the template content will be corrupted and consequent build fails --> the user may fix the issues manually if needed
        //- or the user will not see that template that is expected --> but they can always override it with --force
        //Therefore, we should not fail on missing imports or invalid imports, if this is the case rather restore/build should fail.
        return new MSBuildProject(
                projectToLoad,
                globalProperties,
                toolsVersion: null,
                subToolsetVersion: null,
                _projectCollection,
                ProjectLoadSettings.IgnoreMissingImports | ProjectLoadSettings.IgnoreEmptyImports | ProjectLoadSettings.IgnoreInvalidImports);
    }

    private MSBuildProject? GetLoadedProject(string projectToLoad, string? tfm)
    {
        MSBuildProject? project;
        ICollection<MSBuildProject> loadedProjects = _projectCollection.GetLoadedProjects(projectToLoad);
        if (string.IsNullOrEmpty(tfm))
        {
            project = loadedProjects.FirstOrDefault(project => !project.GlobalProperties.ContainsKey("TargetFramework"));
        }
        else
        {
            project = loadedProjects.FirstOrDefault(project =>
                project.GlobalProperties.TryGetValue("TargetFramework", out string? targetFramework)
                && targetFramework.Equals(tfm, StringComparison.OrdinalIgnoreCase));
        }

        if (project != null)
        {
            return project;
        }
        if (loadedProjects.Any())
        {
            foreach (MSBuildProject loaded in loadedProjects)
            {
                _projectCollection.UnloadProject(loaded);
            }
        }
        return null;
    }
}
